JVM

JVM(二) - 垃圾收集器与内存分配策略

Posted by LinYaoTian on 2018-07-25

概述

Java内存运行时区域中,程序计数器虚拟机栈本地方法栈 三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性。在这几个区域内不需要过多考虑回收的问题,因为方法结束或线程结束时,内存自然就跟随着回收了

Java方法区 则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样。我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的垃圾收集器 所关注的是这部分内存。

对象已死吗

在堆里面存放着 Java 世界几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象哪些还“存活”,哪些已经“死去”。

引用指数算法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效时,计数器就减 1;任何时刻计数器为 0 的对象都是不可能再被使用的。

  • 优点:

    引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利。

  • 缺点:

    难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以主流 Java 虚拟机并没有选择这种算法进行垃圾回收。

可达性算法

JVM 通过可达性分析Reachability Analysis)来判断对象是否存活。

基本思想:通过一系列称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时(用图论来说,就是 GC Roots 到这个对象不可达),则证明此对象是不可达的。

如下图:对象 Object 5Object 6Object 7 虽然互相有关联,但是它们到 GC Roots 是不可达的,所以他们将被判定为是可回收的对象。

Java 中,可作为 GC Roots 的对象包括以下四种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈JNI (Native) 引用的对象
  • 方法区类静态属性引用的对象
  • 方法区常量引用的对象

再谈引用

引用可分为四类:强引用Strong Reference),软引用Soft Reference),弱引用Weak Reference),虚引用Phantom Reference),这四种引用强度依次减弱。

强引用(Strong Reference)

程序代码中普遍存在,类似 Object o = new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会去回收掉被引用的对象。

软引用(Soft Reference)

软引用是用来描述一些还有用但是并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,将会抛出内存溢出异常。

弱引用(Weak Reference)

弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

虚引用(Phantom Reference)

虚引用也称为幽灵引用或幻影引用,是最弱的一种引用关系,JDK提供了PhantomReference类来实现虚引用。为一个对象设置虚引用的唯一目的是:能在这个对象在垃圾回收器回收时收到一个系统通知

生存还是死亡

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

第一次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

第二次标记过程:如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己( this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

不鼓励大家使用这种 finalize() 方法来拯救对象,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。

回收方法区

方法区(或者 HotSpot 虚拟机中的永久带) 的垃圾收集效率远低于堆中。

永久带的垃圾收集主要回收两部分内容:

  • 废弃常量

    回收废弃常量与回收 Java 堆中的对象非常相似。

  • 无用的类

    判定一个类是“无用的类”需同时满足以下三个条件:

    • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
    • 加载该类的 ClassLoader 已经被回收。
    • 该类对应的 java.lang.Class 对象没有在任何地方被引用过,无法在任何地方通过反射访问该类的方法。

    虚拟机可以对满足上述条件的无用类进行回收,这里说的仅仅是“可以”,而不是和对象一样,不使用了就必然会回收。

垃圾收集算法

标记-清除算法

最基础的算法是“标记-清除”(Mark-Sweep)算法,算法分为“标记”和“清楚”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程已在前面说过。

  • 优点:

    实现简单,不需要进行对象进行移动。

  • 缺点:

    1. 标记和清除两个过程的效率不高。
    2. 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致程序在以后的执行过程需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

## 复制算法

基本思路:将内存区域划分成大小相等的两块,每次只用其中的一块。当这一块用完了,就还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。

  • 优点:

    每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

  • 缺点:

    内存大小缩小为原来一半,在对象存活率较高时会频繁进行复制。

现在商业虚拟机都采用了这种收集算法来回收新生代

IBM 研究表明:新生代中对象98%是“朝生夕死”,所以并不需要按照1:1的比例来划分内存空间。将新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 EdenSurvivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的Survivor 空间。HotSpot 虚拟机默认 EdenSurvivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保

内存的分配担保

如果另一块 Survivor 空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

标记-整理算法

老年代需要做分配担保,因此老年代中需要应对所有对象都是100%存活的极端情况,因此复制算法不能直接用于老年代。

所以根据老年代的特点,提出另外一种标记-整理算法,标记过程和标记-清除算法一样,但后续不直接对可回收对象进行回收,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

  • 优点:

    解决了标记-清除算法存在的内存碎片问题。

  • 缺点:

    仍需要对局部对象移动,一定程序上降低了效率。

分代收集算法

根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

HotSpot 的算法实现

枚举根节点

由于在 GC Roots 节点(主要在全局性引用(如静态常量或静态属性)与执行上下文(例如栈帧中的本地变量表))中找引用链操作时若要逐个检查里面的引用,则必然会消耗很多时间。

另外,可达性分析对时间的敏感还体现在 GC 停顿上,因为这项分析工作必须在一个能够确保一致性的快照中进行,因此 GC 进行时必须停顿所有的 Java 执行线程(Sun 将这件事称为 “Stop The World”),才不会出现分析过程中引用对象不断变化的情况。

因此在 HotSpotGC 实现中,是使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载完成的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样 GC 在扫描时就可以直接指导这些信息了。

安全点

HotSpot 没有为每条指令都生成 OopMap,只有程序执行到安全点(Safepoint)才能暂停下来开始 GC 。

安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,具有这些功能的指令才会产生 Safepoint

对于 Safepoint ,在 GC 发生时如何让所有线程都“跑”到最近的安全点上再停顿下来。这里有 2 种方案选择:抢先式中断和主动式中断。

  • 抢先式中断

    不需要线程的执行代码主动配合,在 GC 发生时,首先把所有线程中断,如果发生有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。(现在几乎没有虚拟机采用抢先式中断来暂停线程从而响应 GC 事件

  • 主动式中断

    GC 需要中断线程时,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全区域

Safepoint 机制保证了程序执行时,在不太长时间内就会遇到可进入的 GCSafepoint 。但是程序“不执行’’的时候呢?典型的情况就是线程处于 sleep 状态或者 blocked 状态,这时候线程无法响应 JVM 的中断请求,”走“到安全的地方去中断挂起。对于这种情况就需要安全区域(Safe Region)。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。

在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region,那样,当在这段时间里 JVM 要发起GC 时,就不用管标识自己为 Safe Region 状态的线程了。在线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到信号可以安全离开 Safe Region 的信号为止。

垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

HotSpot 包含的所有收集器如下图(如果两个收集器中存在连线,就说明它们可以搭配使用):

明确两个概念:

  • 并行(Parallel):指多条垃圾线程并行工作,但此时用户线程仍处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上。

Serial 收集器

Serial 收集器是 HotSpot 运行在 Client 模式下的默认新生代收集器。

特点:

  • 在进行垃圾收集时,必须暂停其他所有的工作线程,只用一个CPU或一条收集线程去完成垃圾收集工作。

优点:

  • 简单而高效(与其他收集器的单线程相比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

缺点:

  • 在进行垃圾收集时,必须暂停其他所有的工作线程。

ParNew 收集器

ParNew 是运行在 Server 模式下的虚拟机中首选的新生代收集器。

特点:

  • ParNew 其实就是 Serial 的多线程版,除了使用多条线程进行垃圾回收之外,其余行为和 Serial 收集器一模一样。

优点:

  • 默认开启了收集线程数与 CPU 的数量相同,在 CPU 非常多的情况下,效率能得到很大的提升。

缺点:

  • 由于存在线程切换的开销, ParNew 在单 CPU 的环境中比不上 Serial, 且在通过超线程技术实现的两个 CPU 的环境中也不能100%保证能超越 Serial

Parallel Scavenge 收集器

Parallerl Scavenge 收集器是一个新生代收集器。它也使用复制算法的收集器,又是并行的多线程收集器。但是无法与 CMS 收集器配合工作。

特点:

  • 它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 的目标则是达到一个可控的吞吐量(Throughput)。所谓的吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(虚拟机总共运行了 100 分钟,其中垃圾收集花费 1 分钟,那吞吐量就是 99%)。也被称为“吞吐量优先”收集器。

优点:

  • 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Serial Old 收集器

Serial OldSerial 收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。

Parallel Old 收集器

Parallel OldParallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。

在注重吞吐量和 CPU 资源敏感的场合,可优先使用 Parallel Scavenge + Parallel Old 组合。

CMS 收集器

CMS(Concurrent Mark Sweep) 收集器是以获取最短回收停顿时间为目标的收集器。它是基于“标记-清除”算法的收集器,实现过程分为 4 个步骤:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发标记(CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤任然需要“Stop The World”。初始标记仅仅是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

优点:

  • 并发收集、低停顿。

缺点:

  • CMS 收集器对 CPU 资源非常敏感。在并发阶段,会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  • CMS 收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次 Full GC 的产生。
  • CMS 是一款基于“标记-清除”算法实现的收集器,意味着收集结束时会有大量空间碎片产生。

G1 收集器

G1 是一款面向服务端应用的垃圾收集器。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

特点:

  • 并行和并发G1 能够充分利用多 CPU ,多核环境下的硬件优势。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的就对象以获取更好的收集效果。
  • 空间整理G1 从整体上来看是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。
  • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,他们都是一部分Region(不需要连续)的集合。

内存分配与回收策略

Java 体系中所提倡的自动内存管理最终可以归结为自动化地解决 2 个问题:

  • 给对象分配内存

    往大方向讲,就是在堆上分配(但也可能经过 JIT 编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲(TLAB),将按线程优先在 TLAB上分配。少数情况下会直接分配在老年代。

  • 回收分配给对象的内存

    GC

GC 的区别

  • 新生代 GCMinor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GCMajor GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器策略里就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢10倍以上。

内存分配策略:

对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。

长期存活的对象将进入老年代

虚拟机采用了分代收集的思想来管理内存。虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。

动态对象年龄判断

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringTreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxtenuringThreshold 中要求的年龄。

空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC尽管这次 Minor GC 是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次 Full GC

上述风险的原因:新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。